fix(router): avoid repeated App path decoding#2556
Conversation
|
/bigbonk review for issues |
commit: |
|
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
I traced the full request path for App Router across dev (index.ts), Node prod (prod-server.ts), and the generated RSC entry (which the Workers path also delegates to). This is a correct and well-targeted fix for a real double-decode / middleware-bypass class of bug. Verdict: approve with a couple of non-blocking observations.
What the fix does (and why it's sound)
The change has two complementary parts:
-
Idempotent segment decode (
routing/utils.ts) — broadeningPATH_DELIMITER_REGEXfrom%(2f|23|3f|5c)to%[0-9a-f]{2}makesdecodeRouteSegment/decodeRouteSegmentStrictre-encode every residual%XXescape afterdecodeURIComponent, not just delimiters. I verified this makes the function fully idempotent across a wide input set (normal UTF-8,%2F, double-encoded%2561, traversal shapes..%252fadmin, malformed%C3/%2G, bare%):decode(x) === decode(decode(x))for all of them. This is the crux — even code paths that still decode twice (e.g.findIntercept, the un-migratedmatchRoutecalls inapp-server-action-execution.ts) now produce stable results. -
matchNormalizedRoutefast path (app-rsc-route-matching.ts+ handler) — whencleanPathnameis still the request-boundary-normalized value, route matching skips the redundant re-decode viasplitPathSegments. ThecleanPathnameIsNormalizedflag is correctly flipped tofalseon every path that mutatescleanPathnameto an un-normalized value: middleware rewrite (gated onmiddlewareResult.rewritten, which — verified inapp-middleware.ts— is the only condition under whichcleanPathnamechanges), and each beforeFiles/afterFiles/fallback/action rewrite. Because the boundary value is guaranteed decoded-once (vianormalizePathnameForRouteMatchStrictinnormalizeRscRequest), and decode is now idempotent,matchNormalizedRoute(skip decode) andmatchRoute(decode once) are provably equivalent on normalized input.
Dev/prod/worker parity — verified
- prod-server: reverting the RSC handler call from
normalizedUrlback torawUrlis the right move. Static-asset lookup and the/__vinext/prerender gate still use the decodedpathname, but the RSC handler now decodes exactly once insidenormalizeRscRequestinstead of receiving an already-decoded path and decoding again. The prerender-secret gate stays consistent because%2Fsurvives decoding in both prod-server and the handler. - dev (
index.ts): passingpathname(decoded once at line 4731) as the newnormalizedPathnamearg preventsexecuteMiddleware→resolveMiddlewarePathnamefrom decodingrequest.urla second time. This is the actual dev-side bug being closed — previously/%2561dmincould decode twice to/adminand hit a middleware matcher it should not. - workers: inherits the fix via the built RSC entry; covered by the new
cloudflare-workers/ssr.spec.tscases.
Security scenario is legit
The /%2561dmin case is a genuine middleware-bypass fix: Next.js matches middleware against the decode-once pathname (/%61dmin != /admin, so middleware does not block), and route matching must not decode again to /admin. The traversal cases (..%252fadmin, /api/health/..%252fadmin) correctly stay 404 because %252f remains encoded through decode+normalizePath, so .. can't traverse to /admin. I confirmed all three assertions by simulation.
I ran tests/app-rsc-route-matching.test.ts (27 pass), app-router-malformed-url + app-router-middleware-next-request (20 pass), entry-templates (41 pass), and tsc --noEmit (clean).
Non-blocking observations
-
app-server-action-execution.tsstill callsoptions.matchRoute(options.cleanPathname)(lines 1284, 1420) with an already-normalizedcleanPathname, i.e. it double-decodes. This is now harmless thanks to the idempotency fix, but it's an inconsistency with the handler'smatchCleanPathnameoptimization. Not a regression (pre-existing), and out of scope — but worth a follow-up so the "match a normalized path" contract is applied uniformly. A code comment noting these intentionally rely on decode idempotency would help future readers. -
The
matchNormalizedRouteperf win is entirely dependent on decode idempotency for correctness. Since the idempotency fix alone would restore correctness, the fast path is a pure optimization. Consider a one-line comment at thematchCleanPathnamedefinition stating that skipping decode is safe because the boundary value is already strict-normalized (so callers don't accidentally route an encoded path through it after a future refactor). -
api/healthroute doesn't exist in the fixture, so/api/health/..%252fadminis really just exercising "unknown route → 404". That's fine for the traversal assertion, but if the intent was to prove traversal can't reach an existing sibling route, pointing the second traversal case at a real route (or the existing/api/hello) would make the test stronger.
Overall: clean, minimal, correct, and the idempotency insight makes the whole thing robust rather than relying on catching every call site. Nice work.
|
Review posted to PR #2556. Summary of my analysis: Verdict: Approve (no blocking issues)This is a correct, well-targeted fix for a double-decode / middleware-bypass bug class in App Router routing. Core mechanism (verified sound):
Parity verified across dev ( Security assertions checked — Tests I ran locally: route-matching (27), malformed-url + middleware-next-request (20), entry-templates (41), and Non-blocking notes flagged: (1) |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
I independently re-verified this fix from the ground up: the decode regex change, the fast-path flag lifecycle, dev/prod/worker parity, and the security scenarios. Verdict: approve. The fix is correct, minimal, and closes a real double-decode / middleware-bypass bug class. A couple of non-blocking robustness notes below.
The core mechanism is sound
1. Idempotent segment decode (routing/utils.ts:172). Broadening PATH_DELIMITER_REGEX from %(2f|23|3f|5c) to %[0-9a-f]{2} re-encodes every residual %XX escape after decodeURIComponent, not just delimiters. I confirmed decode(x) === decode(decode(x)) across normal UTF-8 (caf%C3%A9, %E2%9C%93), delimiters (%2F), double-encoded (%2561), percent literals (100%25), and plus/space. The net effect on a double-encoded segment is identity: %2561dmin → (decode) %61dmin → (re-encode) %2561dmin.
2. Full-pipeline correctness (this is the actual bug being fixed). The decode-once-at-boundary + decode-again-at-param pipeline is what produced the bypass. I simulated the end-to-end path:
| Input | OLD (buggy) | NEW (fixed) |
|---|---|---|
/%2561dmin |
/%61dmin → param decode → admin |
/%2561dmin → param decode → %61dmin |
/%2564ashboard |
/%64ashboard → dashboard |
→ %64ashboard |
/%61dmin (single) |
/admin ✓ |
/admin ✓ |
/%64ashboard (single) |
/dashboard ✓ |
/dashboard ✓ |
The OLD pipeline turned /%2561dmin into admin — a genuine middleware bypass. NEW keeps single-encoded paths decoding correctly while stopping double-encoded paths from collapsing to the target. This matches Next.js semantics: native URL.pathname does not decode, so Next.js middleware sees the raw double-encoded path; vinext decodes exactly once for matching and preserves residuals.
Parity verified across all three runtimes
- prod-server (
prod-server.ts:1432): reverting the RSC handler URL fromnormalizedUrlback torawUrlis correct — static-asset lookup still uses decodedpathname, but the RSC handler now decodes exactly once insidenormalizeRscRequestinstead of receiving a pre-decoded path and decoding again. - dev (
index.ts:4962): passing the once-decodedpathnameasnormalizedPathnameto the Pages runner'srunMiddlewarepreventsresolveMiddlewarePathnamefrom decodingrequest.urla second time. This is the dev-side bug. App Router middleware was already safe (it passesnormalizedPathname: cleanPathnameinapp-middleware.ts:249). - workers: inherits via the built RSC entry; covered by the new
cloudflare-workers/ssr.spec.tscases.
Fast-path flag lifecycle is complete
matchNormalizedRoute (skips the redundant decodeRouteSegment via splitPathSegments) is a pure optimization gated by cleanPathnameIsNormalized. I checked all six cleanPathname = assignments in app-rsc-handler.ts (642, 685, 721, 746, 963, 1009) — every un-normalizing mutation clears the flag. The middleware case (642) gates the reset on middlewareResult.rewritten, and I verified in app-middleware.ts:214-232 that cleanPathname is only reassigned when rewritten = true, so the invariant holds. Since the boundary value is strict-normalized and decode is idempotent, matchNormalizedRoute (decode-once) and matchRoute (decode-twice) are provably equivalent on normalized input — I confirmed identical param results for a%61, caf%C3%A9, a%2561, 50%2525, %2F, a%2Fb.
Verification run
tests/app-rsc-route-matching.test.ts+tests/shims.test.ts: 1212 passtsc --noEmitonpackages/vinext: clean
Non-blocking notes
-
Fragile cross-module invariant at
app-rsc-handler.ts:642-643. Line 642 unconditionally assignscleanPathname = middlewareResult.cleanPathname, but the flag reset on 643 is gated onrewritten. This is correct only becauseapplyAppMiddlewaremaintains "cleanPathname changes ⟺ rewritten." If a future refactor normalized/mutatedcleanPathnamewithout settingrewritten, the fast path would silently route an un-normalized path throughmatchNormalizedRoute. Consider a value-based guard (if (cleanPathname !== normalized.cleanPathname) cleanPathnameIsNormalized = false) or a comment documenting the dependency. -
app-server-action-execution.tsstill double-decodes viaoptions.matchRoute(options.cleanPathname)on an already-normalized path. Now harmless thanks to idempotency, but inconsistent with the handler'smatchCleanPathnameoptimization. Worth a follow-up + a comment noting these intentionally rely on decode idempotency. -
Traversal e2e tests only prove "unknown → 404."
/foo/..%252fadminand/api/health/..%252fadmindon't map to real traversal sources, so they exercise "unknown route → 404" rather than "traversal can't reach an existing sibling." Thenot.toContain("Protected admin content")assertion is the real security check and it holds; pointing one case at an existing route would make the intent sharper.
Clean, minimal, correct — the idempotency insight makes the whole thing robust rather than relying on catching every call site. Nice work.
|
Review posted to PR #2556. SummaryVerdict: Approve — correct, minimal fix for a real double-decode / middleware-bypass bug class. What I verified independently
Non-blocking notes flagged
No blocking issues found. |
|
/bigbonk review for issues |
|
@james-elicx Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
|
/bigbonk review for issues |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
I reviewed the current state of the branch (the implementation has evolved considerably from the earlier PATH_DELIMITER_REGEX-broadening approach described in prior comments — it now uses a dedicated raw-matching path). I traced the full request pipeline across dev (index.ts), Node prod (prod-server.ts), the generated RSC entry (Workers inherits it), and the route matcher. Verdict: approve. The fix is correct and well-scoped. A few non-blocking notes below.
What the fix actually does now
Rather than making decodeRouteSegment idempotent, this version introduces a parallel raw matching path:
-
matchRequestRoute+trieMatchRaw/matchRoutePatternRaw(app-rsc-route-matching.ts,route-trie.ts,route-pattern.ts) — route matching runs against the encodedrequestCleanPathnamesplit withsplitPathSegments(no decode). Params are then finalized with a match-once contract:routeHandlerroutes getdecodeMatchedParams(decode once), page routes getcanonicalizeAppPageParams(decode→re-encode, i.e. kept encoded in canonical form). This matches Next.js, where route handlers receive decoded params but pageparamsretain encoding. -
requestCleanPathname(app-rsc-request-normalization.ts) — the original encoded pathname with only basePath +.rscstripped, threaded through so the RSC handler can route on the un-decoded path.matchCleanPathname()uses it only whilecleanPathnameIsRequestPathnameholds; the flag is cleared on everycleanPathnamemutation (middleware rewrite gated onrewritten, and each beforeFiles/afterFiles/fallback/action rewrite). I checked all six assignment sites — coverage is complete. -
Middleware sees the encoded path (
middleware-runtime.ts) —createNextRequestnow buildsNextRequestfrom rawurl.pathnameinstead ofnormalizedPathname, while the matcher (matchPathname) still uses the decodednormalizedPathname. That is exactly Next.js semantics: the matcher decides whether middleware runs (decoded), butrequest.nextUrl.pathnamereflects the encoded request. The new!hasBasePath(requestPathname, basePath)guard also correctly avoids double-prefixing basePath.
Parity — verified
- prod-server: reverting the RSC-handler URL from
normalizedUrlback torawUrlis correct — static-asset lookup still uses decodedpathname, but the handler now decodes exactly once insidenormalizeRscRequest. - dev (
index.ts): the newconfigureServermiddleware capturesreq.__vinextOriginalEncodedUrlbefore Vite normalizes, andmiddlewareUrlis threaded torunMiddleware(..., pathname). This closes the dev-side re-decode. - workers: inherits via the built RSC entry; covered by the new
cloudflare-workers/ssr.spec.tscases.
Security scenarios check out
The interesting interaction: /%2561dmin — the matcher decodes to /admin so the /admin middleware matcher fires, but the middleware body reads request.nextUrl.pathname === "/%61dmin" (encoded), so pathname === "/admin" is false → no block → falls through to matchRequestRoute("/%2561dmin") → 404. Internally consistent, and matches Next.js. I also confirmed the raw path is stricter on traversal: requestCleanPathname is not normalizePath-collapsed, so .. stays a literal segment and cannot resolve to a sibling route (e.g. /foo/..%252fadmin → segments ["foo","..%252fadmin"] → 404). //-collapsing via splitPathSegments is harmless because the protocol-relative guard runs first and the normalized cleanPathname collapses identically.
Verification run (locally)
tests/app-rsc-route-matching.test.ts(27),app-rsc-handler.test.ts(123),app-page-request.test.ts(29),prerender-route-params.test.ts(24) — all passtests/shims.test.ts(1186) +static-image-emission.test.ts(5) — all passtests/middleware-runtime.test.ts+app-router-middleware-next-request.test.ts+middleware-runtime-trailing-slash.test.ts(45) — all passvp checkon all changed source files — format/lint/types clean
Non-blocking notes
-
Dead parameter:
createNextRequest'snormalizedPathname(middleware-runtime.ts:227). SincemwPathnamenow derives fromurl.pathname, thenormalizedPathnameargument is no longer read insidecreateNextRequest(only referenced in a stale comment at :238). It still passes lint because of the comment/call-site, but it is genuinely unused now. Consider dropping the parameter (and updating the comment) so the function signature doesn't imply it participates inmwPathnameconstruction. -
middlewareUrlmay retain a stripped.htmlsuffix in dev (index.ts:4718).middlewareUrlis seeded fromreq.__vinextOriginalEncodedUrl(captured raw inconfigureServer), so it is not subject to the/index.htmland.htmlstripping applied tourljust above (lines 4711–4715). The_next/databranch explicitly resyncsmiddlewareUrl = url, but the.htmlcase doesn't, so a/about.htmlrequest would hand middleware anextUrl.pathnameending in.html. This is an unlikely path for App Router middleware, but it is an asymmetry with the non-encodedurland worth a resync (or a comment explaining why it's intentional). -
Traversal e2e cases still target non-existent routes.
/foo/..%252fadminand/api/health/..%252fadmindon't map to real traversal sources, so they mostly assert "unknown route → 404". Thenot.toContain("Protected admin content")check is the real security assertion and it holds; pointing one case at an existing sibling would sharpen intent.
Overall: correct, minimal, good test coverage across all three runtimes, and the raw-match-with-canonicalization approach cleanly separates "decode for matcher gating" from "route on encoded path." Nice work.
|
Review posted to PR #2556. Verdict: Approve (no blocking issues)The implementation has evolved from the earlier What I verified:
Non-blocking notes flagged:
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
I reviewed the current branch state end-to-end: the raw-matching path (matchRequestRoute / trieMatchRaw / matchRoutePatternRaw), the requestCleanPathname threading, the cleanPathnameIsRequestPathname flag lifecycle, createNextRequest's encoded-pathname switch, dev/prod/worker parity, and the security scenarios. I ran the affected unit suites and tsc/lint locally. Verdict: approve — correct, well-scoped, and thoroughly tested. Two non-blocking cleanliness notes below.
What the fix does (verified sound)
-
Dedicated raw-matching path (
app-rsc-route-matching.ts,route-trie.ts,route-pattern.ts) —matchRequestRoutesplitsrequestCleanPathnamewithsplitPathSegments(no decode), then finalizes params with a match-once contract: route handlers getdecodeMatchedParams(decode once →/api/a%2Fbyieldsa/b), page routes getcanonicalizeAppPageParams(decode→re-encode →/files/a%2561staysa%2561,%2Fstays encoded). This matches Next.js, where route handlers receive decoded params but pageparamsretain encoding. I confirmed all cases against the new unit tests. -
requestCleanPathname(app-rsc-request-normalization.ts) — the original encoded pathname with only basePath +.rscstripped, threaded so the RSC handler can route on the un-decoded path.matchCleanPathname()uses it only whilecleanPathnameIsRequestPathnameholds. I checked all sixcleanPathnamemutation sites inapp-rsc-handler.ts(648, 690, 727, 752, 972, 1018) — every un-normalizing mutation clears the flag (middleware gated onrewritten, each beforeFiles/afterFiles/fallback rewrite unconditionally). Coverage is complete. -
Middleware sees the encoded path (
middleware-runtime.ts) —createNextRequestnow buildsmwPathnamefromurl.pathname(raw encoded) instead ofnormalizedPathname, while the matcher (matchPathnameinexecuteMiddleware) still gates on the decodednormalizedPathname. This is exactly Next.js semantics: the matcher decides whether middleware runs (decoded), butrequest.nextUrl.pathnamereflects the encoded request. The new!hasBasePath(requestPathname, basePath)guard correctly avoids double-prefixing basePath for App Router (raw URL still carries basePath) while re-adding it for Pages dev (Vite-stripped URL). Verified both call paths.
Parity — verified across all three runtimes
- prod-server (
prod-server.ts:1432): reverting the RSC-handler URL fromnormalizedUrlback torawUrlis correct — static-asset lookup still uses the decodedpathname, but the handler now decodes exactly once insidenormalizeRscRequest. - dev (
index.ts):configureServercapturesreq.__vinextOriginalEncodedUrlbefore Vite normalizes;middlewareUrlis threaded torunMiddleware(..., pathname), closing the dev-side re-decode. The basePath resync at 4768-4774 keepsmiddlewareUrlstripped in parity withurl. - workers: inherits via the built RSC entry; covered by the new
cloudflare-workers/ssr.spec.tscases.
Security scenarios check out
/%2561dmin(double-encoded) → matcher decodes to/adminso the/adminmiddleware matcher fires, but the middleware body readsrequest.nextUrl.pathname === "/%61dmin"(encoded), so=== "/admin"is false → no block →matchRequestRoute("/%2561dmin")→ 404. Internally consistent, matches Next.js./%61dmin(single-encoded) → same gating; body sees/%61dmin, raw matcher doesn't decode → 404. ThegetRawPathhelper is a nice touch since Playwright'srequestnormalizes the URL.- Traversal (
..%252fadmin):requestCleanPathnameis notnormalizePath-collapsed, so..stays a literal segment andmatchRequestRoutecan't traverse to a sibling. Raw matching is actually stricter than the normalized path here, which is the safe direction.splitPathSegmentscollapses//viafilter(Boolean), and the protocol-relative guard runs first, so leading//is still caught.
Verification run (local)
app-rsc-route-matching(27),app-rsc-handler(123),app-page-request(29) — passapp-server-action-execution,prerender-route-params,static-image-emission(117 total) — passmiddleware-runtime+app-router-middleware-next-request+middleware-runtime-trailing-slash(45) — passshims(1186) +entry-templates(41) — passtsc --noEmitonpackages/vinext— clean;vp lint— clean on all changed files (the only lint errors are pre-existing TS-version noise intests/cache-runtime-loading.test.ts, unrelated to this PR)
Non-blocking notes
-
Dead parameter:
createNextRequest'snormalizedPathname(middleware-runtime.ts:227). Now thatmwPathnamederives entirely fromurl.pathname(247-251), thenormalizedPathnameargument passed at the call site (line 319) is no longer read inside the function. It still lints clean because the call site passes it, but the signature now implies participation it no longer has. Consider dropping the parameter. -
Stale comment (
middleware-runtime.ts:237-246). The comment block still describes the oldnormalizedPathname-based basePath re-add ("normalizedPathname may already be basePath-stripped ... so for in-basePath requests we re-add"). The logic now keys offurl.pathname+!hasBasePath(...). Worth updating so future readers don't assumenormalizedPathnamedrivesmwPathname. -
app-server-action-execution.tsdouble-decode is now fully eliminated — nice. The oldoptions.matchRoute(options.cleanPathname)calls are replaced with the pre-computedcurrentRouteMatch/currentRoutePathnamethreaded from the handler, so action rerenders route on the request-aware pathname. This is a genuine improvement over the earlier "harmless via idempotency" state. -
Traversal e2e cases still target non-existent routes (
/foo/..%252fadmin,/api/health/..%252fadmin). Thenot.toContain("Protected admin content")assertion is the real check and it holds; pointing one case at an existing sibling would sharpen intent. Minor.
Overall: the raw-match-with-canonicalization approach cleanly separates "decode for matcher gating" from "route on the encoded path," matches Next.js param semantics for both handler and page routes, and closes the double-decode/middleware-bypass class of bug across dev, prod, and Workers. Nice work.
|
Review posted to PR #2556. SummaryVerdict: Approve — correct, well-scoped fix for a double-decode / middleware-bypass bug class in App Router routing, with strong test coverage across dev, Node prod, and Cloudflare Workers. What I verified independently
Non-blocking notes flagged
No blocking issues found. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
Reviewed the current branch state (through 73b9602) end-to-end: the raw-matching path (matchRequestRoute / trieMatchRaw / matchRoutePatternRaw), requestCleanPathname threading, the cleanPathnameIsRequestPathname flag lifecycle, createNextRequest's encoded-pathname switch, the action-execution route-identity threading, dev/prod/worker parity, and the security scenarios. Ran the affected unit suites and tsc/vp lint locally. Verdict: approve — correct, well-scoped, thoroughly tested. Prior review notes #1/#2 (dead param + stale comment) are now fixed by the refactor(middleware): remove stale pathname argument commit.
What I verified independently
-
Raw-matching path (
app-rsc-route-matching.ts,route-trie.ts,route-pattern.ts) —matchRequestRoutesplitsrequestCleanPathnamewithsplitPathSegments(no decode), then finalizes with a match-once contract: route handlers getdecodeMatchedParams(/api/a%2Fb→a/b), page routes getcanonicalizeAppPageParams(/files/a%2561staysa%2561,%2Fstays encoded). Matches Next.js param semantics. New unit cases cover static, dynamic, interception target/source, and route-handler shapes. -
cleanPathnameIsRequestPathnameflag — checked all sixcleanPathnamemutation sites inapp-rsc-handler.ts(648, 691, 727, 752, 972, 1018). Every un-normalizing mutation clears the flag (middleware gated onrewritten, each beforeFiles/afterFiles/fallback rewrite unconditionally).matchCleanPathname()gracefully falls back tomatchRoute(cleanPathname)whenmatchRequestRouteis absent. Coverage is complete. -
Middleware sees the encoded path —
createNextRequestbuildsmwPathnamefromurl.pathname(raw encoded); the matcher (executeMiddleware) still gates on decodednormalizedPathname. This is exactly Next.js semantics (matcher decides whether middleware runs;nextUrl.pathnamereflects the encoded request). The!hasBasePath(requestPathname, basePath)guard correctly re-adds basePath only for Vite-stripped Pages dev URLs. -
Dot-segment / traversal defense — confirmed the WHATWG
URLparser resolves./..and%2e-encoded dot segments inurl.pathnamebeforerequestCleanPathnameis derived, so single-encoded traversal collapses identically to the normalized path. Double-encoded%252fstays a literal segment, so..%252fadmincan't reach a sibling.//collapses viasplitPathSegments'filter(Boolean)on both paths, and the protocol-relative guard runs first. Raw matching is stricter (safe direction). -
Security scenarios —
/%2561dmin→ matcher decodes to/adminso the/adminmatcher fires, but the middleware body reads encoded/%61dmin→ no block →matchRequestRoute("/%2561dmin")→ 404./%61dmin→ same gating, raw matcher → 404. Both hold against the real/adminpage + blocking middleware now in the fixtures.getRawPathis the right call to bypass Playwright's URL normalization on the single-encoded case. -
Parity — prod (
rawUrlrestore sonormalizeRscRequestdecodes exactly once), dev (__vinextOriginalEncodedUrlcapture +middlewareUrlthreading), workers (built RSC entry).app-server-action-execution.tsdouble-decode is now eliminated via threadedcurrentRouteMatch/currentRoutePathname.
Verification run (local)
app-rsc-route-matching(27),app-rsc-handler(123),app-page-request(29) — passapp-server-action-execution,prerender-route-params,static-image-emission(117) — passmiddleware-runtime+app-router-middleware-next-request(32) — passtsc --noEmitonpackages/vinext— clean;vp linton all changed source — clean
Non-blocking notes
-
Error-boundary / access-fallback intercept lookup uses
cleanPathname, notinterceptionPathname.entries/app-rsc-entry.ts:906and:915callfindIntercept(cleanPathname, interceptionContext), whereas the main render path (:828-832,probePageat:884) uses the request-awareinterceptionPathname.interceptionPathnameis already in scope in this handler (destructured at:744). Practical impact is essentially nil — the intercept route still resolves the same entry; only param values would differ and they aren't read here (onlyslotKey/sourcePageSegments). Worth aligning for consistency so a future reader doesn't assume these two lookups are intentionally different. -
Dev
middlewareUrlretains a stripped.htmlsuffix (index.ts:4718).middlewareUrlis seeded fromreq.__vinextOriginalEncodedUrl(captured before Vite normalizes), so it isn't subject to the/index.htmland.htmlstripping applied tourlat:4711-4716. The_next/databranch resyncs (middlewareUrl = urlat:4839) but the.htmlcase doesn't, so a/about.htmldev request would hand middleware anextUrl.pathnameending in.html. Unlikely for App Router middleware but an asymmetry withurl; a resync or a comment explaining the intent would close it. (Flagged in prior reviews; still present.) -
Traversal e2e cases target non-existent routes (
/foo/..%252fadmin,/api/health/..%252fadmin). Thenot.toContain("Protected admin content")assertion is the real check and it holds; pointing one case at an existing sibling would sharpen intent. Minor.
Overall: the raw-match-with-canonicalization approach cleanly separates "decode for matcher gating" from "route on the encoded path," matches Next.js param semantics for both handler and page routes, and closes the double-decode / middleware-bypass class of bug across dev, prod, and Workers. Nice work.
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
Reviewed the current branch end-to-end at 0daa4b7: the raw-matching path (matchRequestRoute / trieMatchRaw / matchRoutePatternRaw), requestCleanPathname threading and its cleanPathnameIsRequestPathname lifecycle, createNextRequest's encoded-pathname switch, the action-execution route-identity threading, areStaticParamsAllowed canonical matching, dev/prod/worker parity, and the security scenarios. Ran the affected unit suites and tsc locally. Verdict: approve — correct, well-scoped, thoroughly tested. Two non-blocking notes.
What I verified independently
1. matchRoutePattern / trieMatch refactor is behavior-preserving. Both are now thin wrappers: matchRoutePatternRaw/trieMatchRaw capture params without decoding, then the public matchRoutePattern/trieMatch call decodeMatchedParams. I confirmed the extraction is byte-for-byte equivalent to the old inline decodeMatchedParams — important because there are ~14 existing callers (navigation-planner, optimistic-routing, metadata-routes, page-request, router shim, pages-data-url) that must keep decoding. No regression risk there.
2. Raw request matching + match-once contract. matchRequestRoute splits requestCleanPathname with splitPathSegments (no decode), then finalizes with decodeMatchedParams for route handlers (/api/a%2Fb → a/b) vs canonicalizeAppPageParams for page routes (/files/a%2561 stays a%2561, %2F stays %2F). Confirmed round-trip stability of canonicalizeAppPageParam (encodeURIComponent(decodeURIComponent(x))) across UTF-8, %2F, double-encoded, and percent literals. Matches Next.js param semantics for both handler and page routes.
3. areStaticParamsAllowed canonicalization (app-page-request.ts:357) is a required companion change — because page params now arrive canonical-encoded (caf%C3%A9) while generateStaticParams returns decoded (café), the new stringParamMatches compares against both staticValue and encodeURIComponent(staticValue). Correct and necessary; without it, encoded static routes would 404 the prerender allow-list.
4. Dot-segment / traversal defense holds — verified the load-bearing claim. I checked the WHATWG URL parser directly: new URL(...).pathname resolves both literal (/foo/../admin) and %2e/%2e%2e-encoded (/foo/%2e%2e/admin) dot segments to /admin before normalizeRscRequest reads url.pathname. So even though requestCleanPathname skips normalizePath, the only thing normalizePath adds beyond the URL parser is // collapsing — which splitPathSegments' filter(Boolean) handles at match time. Double-encoded %252f stays a literal segment so ..%252fadmin can't reach a sibling. The protocol-relative guard runs on raw url.pathname first. Raw matching is stricter than the normalized path (safe direction).
5. cleanPathnameIsRequestPathname flag lifecycle is complete. All six cleanPathname assignment sites in app-rsc-handler.ts (647, 690, 726, 751, 971, 1017) clear the flag; the middleware site (647) gates the reset on rewritten, and I confirmed in app-middleware.ts:299 that cleanPathname is returned unchanged whenever rewritten is false, so the "cleanPathname changes ⟺ rewritten" invariant holds. matchCleanPathname() also degrades gracefully to matchRoute(cleanPathname) when matchRequestRoute is absent.
6. Middleware sees the encoded path. createNextRequest now builds mwPathname from url.pathname (raw encoded); the matcher still gates on decoded normalizedPathname. The !hasBasePath(requestPathname, basePath) guard correctly avoids double-prefixing for App Router (raw URL carries basePath) while re-adding it for Vite-stripped Pages dev URLs. The removed normalizedPathname param and stale comment (flagged in earlier reviews) are gone in 73b9602.
7. Parity + security. prod (rawUrl restore so normalizeRscRequest decodes exactly once), dev (__vinextOriginalEncodedUrl capture + middlewareUrl threading + basePath resync at 4771-4777), workers (built RSC entry). /%2561dmin → matcher decodes to /admin so the matcher fires, but middleware body reads encoded /%61dmin → no block → raw matcher → 404. /%61dmin → 404. app-server-action-execution.ts double-decode is fully eliminated via the threaded currentRouteMatch/currentRoutePathname.
Verification run (local)
app-rsc-route-matching(27),app-rsc-handler(123),app-page-request(29) — passapp-server-action-execution,prerender-route-params,static-image-emission(117) — passshims+middleware-runtime+app-router-middleware-next-request(1218) — passtsc --noEmitonpackages/vinext— clean
Non-blocking notes
-
Dev
middlewareUrlretains a stripped.htmlsuffix (index.ts:4721).middlewareUrlis seeded fromreq.__vinextOriginalEncodedUrl(captured before Vite normalizes), so it isn't subject to the/index.htmland.htmlstripping applied tourlat4711-4716. The_next/databranch resyncs (middlewareUrl = urlat4832) but the.htmlcase doesn't, so a/about.htmldev request would hand middleware anextUrl.pathnameending in.html. Unlikely for App Router middleware but an asymmetry withurl— a resync or a comment would close it. (Persistent across prior reviews.) -
Fragile cross-module invariant at
app-rsc-handler.ts:647. The flag reset on 648 is gated onmiddlewareResult.rewritten, correct only becauseapplyAppMiddlewaremaintains "cleanPathname changes ⟺ rewritten." A value-based guard (cleanPathnameIsRequestPathname &&= cleanPathname === requestCleanPathnameafter the assignment) or a one-line comment documenting the dependency would make a future middleware refactor safer. Not a bug today. -
Traversal e2e cases target non-existent routes (
/foo/..%252fadmin,/api/health/..%252fadmin). Thenot.toContain("Protected admin content")assertion is the real check and it holds; pointing one at an existing sibling would sharpen intent. Minor.
Overall: the raw-match-with-canonicalization approach cleanly separates "decode for matcher gating" from "route on the encoded path," matches Next.js param semantics for both handler and page routes, and closes the double-decode / middleware-bypass class of bug across dev, prod, and Workers. Nice work.
|
Review posted to PR #2556. SummaryVerdict: Approve — no blocking issues. This is a correct, well-scoped fix for a double-decode / middleware-bypass bug class in App Router routing. What I verified independently (at commit
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
I independently re-verified the current branch state (through 7aa4a0b) end-to-end: the raw-matching path (matchRequestRoute / trieMatchRaw / matchRoutePatternRaw), requestCleanPathname threading and its cleanPathnameIsRequestPathname lifecycle, createNextRequest's encoded-pathname switch, the action-execution route-identity threading, areStaticParamsAllowed canonicalization, dev/prod/worker parity, and the security scenarios. Ran the affected unit suites and tsc locally. Verdict: approve — correct, well-scoped, thoroughly tested.
What I verified independently
-
Raw-matching path + match-once contract (
app-rsc-route-matching.ts:171-180).matchRequestRoutesplitsrequestCleanPathnamewithappRscPathnameParts(url, true)(no decode), then finalizes params by route kind:routeHandlerroutes getdecodeMatchedParams(decode once —/api/a%2Fb→a/b), page routes getcanonicalizeAppPageParams(decode→re-encode —/files/a%2Fbstaysa%2Fb,/files/a%2561staysa%2561). Matches Next.js param semantics for both handler and page routes. Theroute-pattern.ts/route-trie.tsrefactor into*Raw+decodeMatchedParamswrappers is behavior-preserving for the ~14 existing decode-once callers. -
cleanPathnameIsRequestPathnameflag lifecycle is complete. All sixcleanPathnamemutation sites inapp-rsc-handler.ts(648, 691, 727, 752, 972, 1018) clear the flag — middleware gated oncleanPathname !== normalized.cleanPathname(a value-based guard, which resolves the fragile-rewritten-coupling note from earlier reviews), each beforeFiles/afterFiles/fallback/action rewrite unconditionally.matchCleanPathname()degrades gracefully tomatchRoute(cleanPathname)whenmatchRequestRouteis absent. -
Middleware sees the encoded path (
middleware-runtime.ts).createNextRequestbuildsmwPathnamefromurl.pathname(raw encoded); the matcher (executeMiddleware) still gates on the decodednormalizedPathname. ThebasePath && hadBasePath && !hasBasePath(requestPathname, basePath)guard correctly re-adds basePath only for Vite-stripped Pages dev URLs. The deadnormalizedPathnameparam + stale comment (flagged in prior reviews) are removed fromcreateNextRequest; the param correctly remains onrunMiddleware/executeMiddlewarewhere it still drives matcher gating. -
Traversal / dot-segment defense — verified the load-bearing claim directly.
requestCleanPathnameskipsnormalizePath, but I confirmed the WHATWGURLparser resolves literal (/foo/../admin),%2e/%2E-encoded (/foo/%2e%2e/admin), and mixed (.%2e) dot segments to/adminbeforenormalizeRscRequestreadsurl.pathname. Double-encoded%252f/ single-encoded%2fstay literal segments (/foo/..%252fadmin→["foo","..%252fadmin"]→ 404), so..can't reach a sibling.//collapses viasplitPathSegments'filter(Boolean); the protocol-relative guard runs on rawurl.pathnamefirst. Raw matching is stricter than the normalized path — the safe direction. -
areStaticParamsAllowedcanonicalization (app-page-request.ts) is a required companion change: page params now arrive canonical-encoded whilegenerateStaticParamsreturns decoded, so the newstringParamMatchescompares against bothstaticValueandencodeURIComponent(staticValue).canonicalizeAppPageParamand this check both key offencodeURIComponent, so they agree. Without it, encoded static routes would 404 the prerender allow-list.prerender-route-params.tssimilarly accepts either encoded or decoded values. -
Security scenarios.
/admin→ 403 (middleware matcher fires on decoded path and blocks);/%2561dmin→ 404 (matcher sees%61dmin≠admin, raw matcher finds no route);/%61dmin→ 404 (getRawPathbypasses Playwright's URL normalization; raw matcher doesn't decode). Confirmed against the real/adminpage + blocking middleware now in the fixtures, and against the newmatchRequestRouteunit cases (/%61dmin→ null,/%2561dmin→ null). -
Parity + action execution. prod (
rawUrlrestore sonormalizeRscRequestdecodes exactly once — comment documents intent), dev (__vinextOriginalEncodedUrlcapture +middlewareUrlthreading + basePath resync), workers (built RSC entry).app-server-action-execution.tsdouble-decode is fully eliminated via the threadedcurrentRouteMatch/currentRoutePathname. The error-boundary/access-fallback intercept lookups inentries/app-rsc-entry.ts(906/915) now use the request-awareinterceptionPathname, resolving the consistency gap from a prior review.
Verification run (local)
app-rsc-route-matching(27),app-page-request(29),app-rsc-handler(123) — passmiddleware-runtime,app-router-middleware-next-request,app-server-action-execution,prerender-route-params,static-image-emission(149) — passentry-templates(41),routing,route-sorting(201) — passtsc --noEmitonpackages/vinext— clean
Non-blocking notes
-
Dev
middlewareUrlretains a stripped.htmlsuffix (index.ts:4721).middlewareUrlis seeded fromreq.__vinextOriginalEncodedUrl(captured before Vite normalizes), so it isn't subject to the/index.html/.htmlstripping applied tourlat4711-4716. The_next/databranch resyncs (middlewareUrl = urlat4832) and the basePath branch resyncs, but the.htmlcase doesn't — a/about.htmldev request would hand middleware anextUrl.pathnameending in.html. Unlikely for App Router middleware, but an asymmetry withurl; a resync (or a comment explaining the intent) would close it. Persistent across prior reviews. -
Traversal e2e cases target non-existent routes (
/foo/..%252fadmin,/api/health/..%252fadmin). Thenot.toContain("Protected admin content")assertion is the real check and it holds; pointing one at an existing sibling would sharpen the "can't traverse to a real route" intent. Minor.
Overall: the raw-match-with-canonicalization approach cleanly separates "decode for matcher gating" from "route on the encoded path," matches Next.js param semantics for both handler and page routes, and closes the double-decode / middleware-bypass class of bug across dev, prod, and Workers. The value-based flag guard and the eliminated action-execution double-decode are genuine hardening improvements over earlier iterations. Nice work.
|
Review posted to PR #2556. SummaryVerdict: Approve — no blocking issues. I independently verified the current branch (through What I confirmed
Non-blocking notes
The implementation is correct, minimal, well-tested across dev/prod/Workers, and the value-based flag guard + eliminated action double-decode are genuine improvements over earlier iterations. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
Reviewed the current branch end-to-end at 2e255c8, focusing on the four commits added since the last review (3c6a84c encoded route parity, 22aaa31 raw config path matching, 37a7447 raw encoded route identity, 2e255c8 encoded dot-segment canonicalization). Ran the affected unit suites and tsc/vp lint locally. Verdict: approve — correct, well-scoped, thoroughly tested across dev, Node prod, and Cloudflare Workers.
What I verified independently
1. New: WHATWG dot-segment canonicalization (request-pipeline.ts + 2e255c8). canonicalizeRequestPathname sets url.pathname = pathname on a throwaway URL to remove literal and %2e-encoded dot segments while preserving unrelated percent escapes. I confirmed byte-for-byte preservation of /%61bout/, /dynamic/a%2561/b%2Fc, /%2f, /%5c, /%252f, and dot-segment removal of /%2e/about→/about, /x/%2E%2e/old-about→/old-about. This is the correct fix for the Node dev/prod-server paths, which route on raw req.url strings and previously did not get the dot-segment resolution that the App Router gets for free via the Request/URL constructor (nodeToWebRequest). Parity is now consistent: App Router (Request-based) and Pages (raw-string) resolve dot segments identically before config/basePath/route matching.
2. Ordering is safe. isOpenRedirectShaped runs before canonicalization in both index.ts (4714-4719) and prod-server.ts (1640), so ///\/%5C/%2F protocol-relative shapes are rejected first. canonicalizeRequestPathname never throws on malformed percent-encoding (leaves bare % untouched), so normalizePathnameForRouteMatchStrict still catches /%E0%A4%A→400 downstream. Verified both directly.
3. matchRoute raw-param extraction (3c6a84c). matchRoute now selects the route on the normalized parts (appRscPathnameParts(url, false)) but extracts param values from the raw parts via extractRawParamsForMatchedRoute, then applies route-kind canonicalization. I checked the load-bearing alignment invariant: normalizePathnameForRouteMatch re-encodes decoded delimiters (%2F stays %2F, a decoded / is re-encoded), so decoding never changes segment count — raw and normalized part arrays stay index-aligned. No misalignment risk.
4. isAppRouteHandlerRoute classification (3c6a84c). Correctly keys off routeHandler != null || typeof __loadRouteHandler === "function" so a lazily-loaded route handler is classified as a handler (decode-once) rather than a page (canonical-encode) before its module loads. __loadRouteHandler is a real generated-manifest field (app-rsc-manifest.ts:323). Good catch — classification is now load-order-stable.
5. Raw config-source matching (22aaa31/37a7447). Config redirects/rewrites/headers now match requestCleanPathname (raw encoded) via the cleanPathnameIsRequestPathname ? requestCleanPathname : cleanPathname pattern, consistently applied at every site. This matches Next.js — /%72ewrite no longer aliases a /rewrite config source. applyRewrite matches on sourcePathname (raw) and substitutes captures from paramsPathname (raw) so Location values retain original encoding. Flag reset at app-rsc-handler.ts:660 now uses didMiddlewareRewrite || cleanPathname !== normalized.cleanPathname (rewrite-authoritative + value-based), which is a hardening over the earlier pure value-based guard.
6. i18n locale transform extended to config headers (37a7447). Verified against Next.js load-custom-routes.ts processRoutes: the :nextInternalLocale source prefix is applied to all route types (redirect/rewrite/header) when i18n is set, while the per-default-locale literal variants are emitted only for redirects (defaultLocales is populated only for type === 'redirect'). vinext's applyLocaleToRoutes mirrors this exactly (defaultLocales = type === "redirect" ? [i18n.defaultLocale] : []). The "destination" in r narrowing correctly handles NextHeader (no destination). The retained-unprefixed-source push (line 1642) is the pre-existing documented divergence, now applied consistently.
Verification run (local)
request-pipeline(106),app-rsc-route-matching(29),app-rsc-handler(128) — passrouting,app-page-request,prerender-route-params,middleware-runtime,pages-request-pipeline,app-server-action-execution,static-image-emission(359) — passshims(1187) +app-rsc-response-finalizer— passtsc --noEmitonpackages/vinext— clean;vp lint— clean on changed source (only pre-existing TS7-version noise intests/cache-runtime-loading.test.ts, unrelated)
Non-blocking notes
-
Dev
middlewareUrlretains a stripped.htmlsuffix (index.ts:4736).middlewareUrlis seeded fromcanonicalOriginalUrl(canonicalized raw), so it isn't subject to the/index.html/.htmlstripping applied tourlat4726-4731(routeUrlgets its own stripping;middlewareUrldeliberately keeps.htmlso middleware sees the real request). The_next/databranch resyncs (middlewareUrl = urlat4864) and the basePath branch resyncs, but the.htmlcase doesn't — a/about.htmldev request hands middleware anextUrl.pathnameending in.html. This appears intentional (middleware should distinguish a real.htmlrequest), but the asymmetry with the resyncing branches is worth a one-line comment to prevent a future "fix." Persistent across prior reviews. -
Traversal e2e cases target non-existent routes (
/foo/..%252fadmin,/api/health/..%252fadmin). Thenot.toContain("Protected admin content")/status 404assertions are the real check and they hold; the new2e255c8dot-segment specs (/docs/x/%2e%2e/rewrite-about→ 200,/docs/%2e%2e/about→ 404) meaningfully sharpen the "single-encoded dot resolves, double-encoded delimiter stays literal" intent. Minor. -
canonicalizeRequestPathnamealso percent-encodes special ASCII (space,<,>, backtick,",#,?) viaurl.pathname, not just dot segments. Harmless in practice (real HTTP request URLs arrive already-encoded on the wire, and App Router'sRequestconstructor applies the identical encoding, so parity holds) — but the docblock claims only dot segments change while "other percent escapes remain untouched." Consider noting that non-percent special chars are also normalized to their canonical encoding, so a future reader doesn't assume the function is a pure dot-segment resolver.
Overall: the raw-match-with-canonicalization approach cleanly separates "decode for matcher gating" from "route on the encoded path," matches Next.js param and config-source semantics for handler and page routes, and the new dot-segment canonicalization closes the last dev/prod parity gap with the Request-based App Router path. Nice work.
|
Review posted to PR #2556. SummaryVerdict: Approve — no blocking issues. I reviewed the current branch at What I checked (new commits)
VerificationRan ~1900 tests across request-pipeline, route-matching, handler, page-request, prerender, middleware, action-execution, shims, and finalizer suites — all pass. Non-blocking notes
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
Focused re-review of the latest commit 0322d8b ("restore encoded path parity after main merge") on top of the already-approved raw-matching implementation. The core raw-match + canonicalization approach and the i18n/config-source changes remain sound (verified against Next.js load-custom-routes.ts). The dev basePath switch to hasBasePath/stripBasePath is a genuine improvement (it fixes a /app vs /application prefix collision and correctly folds in the Vite base). The prod-server 404 reorder is safe — missingBuildAsset is only set for _next/static / app-asset-prefix paths, which never legitimately resolve to a public static-file signal.
One thing in the merge-parity commit stood out that I don't think matches Next.js.
areStaticParamsAllowed case-insensitive comparison diverges from Next.js (non-blocking, but likely wrong)
app-page-request.ts:361-362 changed the static-param comparison from exact to case-insensitive:
// Route matching is case-insensitive, so preserve that behavior for the
// encoded comparison too.
const stringParamMatches = (value: string, staticValue: string): boolean =>
value.toLowerCase() === encodeURIComponent(staticValue).toLowerCase();Two problems:
-
The stated justification doesn't hold. vinext route matching is case-sensitive:
route-trie.ts:184doesnode.staticChildren.get(segment)(a case-sensitiveMaplookup), and there is notoLowerCaseanywhere inrouting/. Next.js is also case-sensitive here — the runtimedynamicParams=falsegate is an exactstaticPaths?.includes(resolvedUrlPathname)(base-server.ts:2171), andencodedPathnamein the prerender manifest is built with plainencodeURIComponent(no lowercasing). So the comment's premise is incorrect. -
It's unnecessary for the percent-encoding parity it targets, and it over-broadens matching. Both sides already normalize hex to uppercase:
encodeURIComponentemits%2F, and the runtime value arrives viacanonicalizeAppPageParam=encodeURIComponent(decodeURIComponent(...)), which also emits%2F. So%2f/%2Fparity is already handled by the old exact comparison. The only new behavior.toLowerCase()introduces is on literal ASCII case. Simulated:generateStaticParams(dynamicParams=false)request OLD (exact) NEW ( .toLowerCase)Next.js { id: "AbC" }/AbC200 200 200 { id: "AbC" }/abc404 200 404 { id: "a/b" }/a%2fb200 200 200 The middle row is the regression: with the new code a case-mismatched request renders a
dynamicParams=falseroute that Next.js would 404. It also disagrees with the sibling encoder inprerender-route-params.ts:207-209, which stays exactencodeURIComponent.
No test distinguishes the two (all existing cases use uppercase-canonical %XX), so this passes CI silently. Suggest reverting to the exact comparison (value === encodeURIComponent(staticValue)) and dropping the "route matching is case-insensitive" comment — or, if a real case-insensitive scenario motivated this, add a fixture that exercises it and make the trie matcher case-insensitive too so the two stay consistent.
Verdict
Approve — the encoded-path fix is correct and well-tested end-to-end; the .toLowerCase() divergence above is the only substantive concern and is narrow (literal-ASCII-case dynamic params under dynamicParams=false). Worth fixing in this PR since it's a fresh, untested divergence, but not a blocker for the primary bug class this PR closes.
|
Review posted to PR #2556. SummaryI focused this review on the newest commit ( Verdict: Approve — the core encoded-path fix is correct and thoroughly tested. One fresh, untested divergence flagged (non-blocking). Confirmed sound
Main finding:
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(router): avoid repeated App path decoding
Focused re-review of the six commits added since the last review (0322d8b), which are all about static-params validation for chained + parallel App Router routes (a6f6b71 exact-match, fef7db4 chain parent params, 921f096 preserve ownership, 320ffaa per-result ownership, 7d5e08c preserve after empty parallel generation, 48117ae chain parallel by branch). The core raw-matching / encoded-path fix from earlier reviews is unchanged. Verdict: approve — correct, well-scoped, thoroughly tested. The one substantive concern from my prior review is now resolved.
Prior-review finding is fixed
The areStaticParamsAllowed case-insensitive divergence I flagged on 0322d8b is resolved (app-page-request.ts:389-390): the comparison is now exact value === encodeURIComponent(staticValue), with an accurate comment ("Dynamic route-pattern matching is case-insensitive, but the concrete values returned by generateStaticParams are not"). New fixtures + e2e (static-param-case-parity.spec.ts) explicitly assert /scalar/AbC → 200 while /scalar/abc, /scalar/aBc → 404, and mirror Next.js' app-prefetch-static route shape. This is the exact case-sensitive behavior Next.js has (base-server.ts exact includes), so the parity gap is closed and now guarded by a regression test.
What I verified in the new static-params logic
-
Chained (primary loader-tree) generation (
generateChainedStaticParams) walks sources top-down, merging each child into its parent combo and preserving parent combos on empty child results — matching Next's non-PPR generation. The route-group boundary fix (getLayoutGenerateStaticParamsBoundarynow skips@slot/(group)segments) correctly attributes a grouped layout'sgenerateStaticParamsto the preceding visible dynamic segment. Confirmed by the(default)force-dynamic fixture and the parent-chain e2e (/EU/En→ 200;/eu/En,/EU/en,/EU/Fr→ 404). -
Independent (parallel-branch) chains are grouped by
independentChainindex and each validated against the request withallowMissingValues=true; the pure-chained case is validated exact. I traced the ownership model end-to-end:- Empty-object parallel result (
[{}, {slug:"parallel"}]) correctly preserves the primary combo and adds the branch's own combo (preserves ownership for each parallel generator result). - Parallel generator that omits a key falls back to the primary chain's value via
primaryParamsseeding, and the missing-keyallowMissingValuespath only permits keys a slot genuinely doesn't own. - Empty parallel result preserves the primary combos rather than dropping them.
- The legacy raw-array form (
normalizeGenerateStaticParams) still produces one identity-keyed chain per source, so AND-across-sources validation is preserved (requires every segment ... to allow the paramsstill 404s on the mismatched leaf). Backward compatible.
- Empty-object parallel result (
-
Production force-dynamic skip (
app-page-dispatch.ts:792) —!(isProduction && isForceDynamic)skips the generated-path gate only in prod, matching Next.js (force-dynamic routes are absent from the prerender manifest) while dev keeps exact-matching. Documented with a comment and covered by both a unit test and thekeeps force-dynamic routes on the dev exact-match gatee2e.
Verification run (local)
app-page-request(37),app-segment-config(36),app-page-dispatch(64) — passapp-rsc-route-matching,prerender-route-params,routing(167) — passapp-rsc-handler(128),middleware-runtime(16),request-pipeline(106),pages-request-pipeline(82) — passvp checkon the three changed source files — format/lint/types clean
Non-blocking notes
-
allowMissingValues=trueis load-bearing for parallel-slot ownership (app-page-request.ts:398). It's correct here — a slot that doesn't own a param shouldn't constrain it, and the primary chain still supplies that param's value inresult.staticParams— but the permissiveness is subtle. A one-line comment at theallowMissingValuesbranch noting why a missing key is permitted (slot doesn't own the param; the owning chain validates it) would help future readers distinguish it from an accidental bypass. -
requestParamsseeding ingenerateIndependentStaticParams(app-page-request.ts:463) —{...requestParams, ...parent.params}lets request-controlled values flow intogenerateStaticParamsfor parent params not produced by a preceding source. This is fine because the finalareStaticParamsAllowed` gate is against the generated set, not the request, but it's worth confirming no generator relies on that input being trusted. Not a bug in the validation path. -
Dev
middlewareUrl.html-suffix asymmetry (persistent from prior reviews) and traversal e2e cases targeting non-existent routes — both still stand as minor, non-blocking.
Overall: the static-params rework cleanly separates chained top-down generation from independent parallel-branch chains, matches Next.js ownership + case-sensitivity semantics, and the prior case-insensitive divergence is fixed and regression-tested. Nice work.
|
Review posted to PR #2556. SummaryVerdict: Approve — no blocking issues. I focused this pass on the six commits added since my last review ( Key finding: prior review concern is resolvedThe What I verified
VerificationRan the affected suites locally ( Non-blocking notes
|
Summary
Test coverage